# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Gitlab::BackgroundMigration::MigrateSharedVulnerabilityIdentifiers, :migration,
  feature_category: :vulnerability_management do
  let(:migration) { Gitlab::BackgroundMigration::MigrateSharedVulnerabilityIdentifiers } # rubocop: disable RSpec/DescribedClass
  let(:report_types) { { cluster_image_scanning: 7, generic: 99 } }

  let(:namespaces) { table(:namespaces) }
  let(:projects) { table(:projects) }

  let(:vulnerability_occurrences) { table(:vulnerability_occurrences) }
  let(:vulnerability_occurrence_identifiers) { table(:vulnerability_occurrence_identifiers) }
  let(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
  let(:vulnerability_reads) { table(:vulnerability_reads) }
  let(:vulnerability_scanners) { table(:vulnerability_scanners) }

  let(:namespace_a) { namespaces.create!(name: "test-1", path: "test-1") }
  let(:namespace_b) { namespaces.create!(name: "test-2", path: "test-2") }

  let(:project_a) { projects.create!(namespace_id: namespace_a.id, project_namespace_id: namespace_a.id) }
  let(:project_b) { projects.create!(namespace_id: namespace_b.id, project_namespace_id: namespace_b.id) }

  let!(:finding_a) { finding(project_id: project_a.id, report_type: report_types[:cluster_image_scanning]) }
  let!(:finding_b) { finding(project_id: project_b.id, report_type: report_types[:generic]) }

  def vulnerability_scanner(params = {})
    row = vulnerability_scanners.find_or_initialize_by(project_id: params[:project_id])

    attrs = {
      external_id: "starboard_trivy",
      name: "Trivy (via Starboard Operator)"
    }.merge(params)

    row.tap { |r| r.update!(attrs) }
  end

  def vulnerability_identifier(params = {})
    attrs = {
      project_id: params[:project_id],
      external_id: "CVE-2018-1234",
      external_type: "CVE",
      name: "CVE-2018-1234",
      fingerprint: SecureRandom.hex(20)
    }.merge(params)

    vulnerability_identifiers.create!(attrs)
  end

  def vulnerability_occurrence(params = {})
    ActiveRecord::Base.transaction do # rubocop: disable Database/MultipleDatabases
      scanner_id = params[:scanner_id] || vulnerability_scanner(project_id: params[:project_id]).id
      identifier_id = params[:identifier_id] || vulnerability_identifier(project_id: params[:project_id]).id

      attrs = {
        project_id: params[:project_id],
        scanner_id: scanner_id,
        primary_identifier_id: identifier_id,
        report_type: report_types[:generic],
        project_fingerprint: SecureRandom.hex(20),
        location_fingerprint: SecureRandom.hex(20),
        uuid: SecureRandom.uuid,
        severity: "medium",
        confidence: "unknown",
        name: "CVE-2018-1234",
        raw_metadata: "{}",
        metadata_version: "cluster_image_scanning:1.0"
      }.merge(params)

      vulnerability_occurrences.create!(attrs).tap do |occurrence|
        vulnerability_occurrence_identifiers.create!(occurrence_id: occurrence.id,
          identifier_id: identifier_id)
      end
    end
  end

  def finding(...)
    vulnerability_occurrence(...).becomes(migration::Finding) # rubocop: disable Cop/AvoidBecomes
  end

  def identifier(...)
    vulnerability_identifier(...).becomes(migration::Identifier) # rubocop: disable Cop/AvoidBecomes
  end

  describe described_class::Finding do
    describe ".to_process" do
      subject { described_class.to_process }

      before do
        finding_a.identifiers.update_all(project_id: project_b.id)
      end

      it "returns affected occurrences" do
        expect(subject).to contain_exactly(finding_a)
      end
    end
  end

  describe described_class::Identifier do
    describe "::find_or_create_id_for" do
      let(:identifier) { finding_a.identifiers.first }

      subject { described_class.find_or_create_id_for(project_a.id, identifier) }

      context "with matching identifier" do
        it "does not create a new identifier" do
          expect { subject }.not_to change { vulnerability_identifiers.count }
        end

        it "returns the identifier ID" do
          expect(subject).to be(identifier.id)
        end
      end

      context "without matching identifier" do
        before do
          identifier.destroy!
        end

        it "creates a new identifier" do
          expect { subject }.to change { vulnerability_identifiers.count }.by(1)
        end

        it "returns the identifier ID" do
          expect(subject).to be(vulnerability_identifiers.last.id)
        end

        it "copies attributes" do
          attrs = identifier.attributes.except("id", "created_at", "updated_at")
          expected_attrs = attrs.merge("project_id" => finding_a.project_id)

          subject

          expect(vulnerability_identifiers.last).to have_attributes(expected_attrs)
        end
      end
    end
  end

  describe "#perform" do
    let(:migration_attrs) do
      {
        start_id: vulnerability_occurrences.minimum(:id),
        end_id: vulnerability_occurrences.maximum(:id),
        batch_table: :vulnerability_occurrences,
        batch_column: :id,
        sub_batch_size: 100,
        pause_ms: 0,
        connection: ApplicationRecord.connection
      }
    end

    subject { described_class.new(**migration_attrs).perform }

    before do
      finding_a.identifiers.update_all(project_id: project_b.id)
    end

    it "creates new identifiers" do
      expect { subject }.to change { vulnerability_identifiers.count }.by(1)
    end

    it "creates identifiers with correct attributes" do
      existing = finding_a.identifiers.first
      attrs = existing.attributes.except("id", "created_at", "updated_at")
      expected_attrs = attrs.merge("project_id" => finding_a.project_id)

      subject

      expect(vulnerability_identifiers.last).to have_attributes(expected_attrs)
    end

    it "updates associations" do
      subject

      expect(finding_a.reload.identifiers).to contain_exactly(migration::Identifier.last)
    end

    it "does not alter correct findings" do
      expect { subject }.not_to change { finding_b.reload.attributes }
    end

    it "does not alter correct identifiers" do
      expect { subject }.not_to change { finding_b.reload.identifiers.map(&:attributes) }
    end

    context "with existing identifier and matching fingerprint" do
      let(:fingerprint) { finding_a.identifiers.first.fingerprint }
      let!(:existing_identifier) { identifier(project_id: finding_a.project_id, fingerprint: fingerprint) }

      it "does not create a new identifier" do
        expect { subject }.not_to change { vulnerability_identifiers.count }
      end

      it "reuses the identifier" do
        expect { subject }.to change { finding_a.reload.identifiers }.to([existing_identifier])
      end
    end

    context "when finding has multiple identifiers" do
      let!(:identifiers) do
        3.times do
          vulnerability_occurrence_identifiers.create!(occurrence_id: finding_a.id,
            identifier_id: identifier(project_id: finding_b.project_id).id)
        end
      end

      let(:affected_identifiers) do
        vulnerability_occurrence_identifiers.where(occurrence_id: migration::Finding.to_process)
      end

      it "preserves identifier count" do
        expect { subject }.not_to change { finding_a.identifiers.count }.from(4)
      end

      it "corrects all affected identifiers" do
        expect { subject }.to change { affected_identifiers.count }.from(4).to(0)
      end
    end
  end
end
